教程区块链区块链基础知识第19章 恒定乘积公式与流动性池合约

本页目录

19.1 恒定乘积公式与核心算法

19.1.1 从订单簿到自动化做市商

传统中心化交易所(如股票交易所或中心化加密交易所)依赖订单簿(Order Book)撮合交易:买方挂出「愿意以某价格买入」的限价单,卖方挂出「愿意以某价格卖出」的限价单,系统寻找双方价格重叠的订单进行撮合。这种模式的痛点在于:若某一价格区间缺乏挂单,则大额交易会面临巨大滑点(Slippage);同时,专业做市商需要持续在订单簿两侧挂单,准入门槛极高。

AMM 的核心思想是用一段数学公式替代人工订单簿。流动性提供者(Liquidity Provider, LP)将一对代币存入一个流动性池(Liquidity Pool),池中的两种代币数量始终遵循一条预设的定价曲线(Pricing Curve)。任何交易者无需等待对手方出现,只需将代币 A 投入池中,即可按曲线规定的价格自动换回代币 B——合约本身就成了「永不休息的全自动做市商」。

Uniswap 于 2018 年首次在以太坊主网上大规模实现了恒定乘积做市商(Constant Product Market Maker, CPMM),成为 DeFi Summer 的基础设施层。本章将以 Uniswap V2 为蓝本,从公式推导、合约实现到经济分析,完成一个最小可用的「Pair 合约」。

19.1.2 恒定乘积公式的数学推导

设池中有代币 XX 的数量为 xx,代币 YY 的数量为 yy。CPMM 的核心约束是一条极其简洁的方程:

\[

x \times y = k

\]

其中 kk 为恒定乘积常数。在无手续费且无流动性增减的情况下,kk 保持不变。

交换推导:假设某用户向池中投入 Δx\Delta x 个代币 XX,则池中 XX 的储备变为 x+Δxx + \Delta x。为维持乘积 kk 恒定,池中剩余代币 YY 的数量必须调整为 yy'

\[

(x + \Delta x) \times y' = k \quad \Rightarrow \quad y' = \frac{k}{x + \Delta x}

\]

用户能够获得的代币 YY 数量即为两者的差值:

\[

\Delta y = y - y' = y - \frac{x \times y}{x + \Delta x} = \frac{y \times \Delta x}{x + \Delta x}

\]

这个公式揭示了一个关键直觉:池中两种代币的储备比例越不平衡,同样的输入 Δx\Delta x 能换回的输出 Δy\Delta y 就越少。这正是滑点的数学根源。同时,一个美妙的边界条件是:当 Δx\Delta x \to \infty 时,Δyy\Delta y \to y——这意味着理论上你永远无法把池中一种代币全部换走,流动性永不枯竭

19.1.3 AMM 价格曲线与即时价格

对恒定乘积方程 x×y=kx \times y = k 两边求导,可得曲线在点 (x,y)(x, y) 处的切线斜率。我们关心的边际价格(Instantaneous Price),即当前时刻 1 个代币 XX 以代币 YY 计价的价格,为:

\[

P = \frac{y}{x}

\]

当一笔交易买入代币 XX(即向池中增加 YY 并取出 XX)后,xx 减小而 yy 增大,新价格 P=y/xP' = y' / x' 随之上升——合约自动「涨价」抑制进一步购买。反之亦然。价格发现被编码在储备量的变化之中,无需外部喂价。

CPMM 的价格曲线在坐标系中是一条双曲线 y=k/xy = k / x。与订单簿的阶梯式深度图不同,AMM 的深度由总流动性(即 kk 的大小)直接决定:kk 越大,同样的交易量对价格的影响越小,滑点越低。

19.1.4 LP Token 经济学与无常损失

流动性提供者将代币对存入池中后,合约会铸造一种池份额代币(LP Token)作为凭证。LP Token 本身通常也是标准 ERC-20 代币,代表其对池中两种资产的所有权比例。

首次添加流动性时,铸造的 LP Token 总量为:

\[

\text{minted} = \sqrt{x \times y} - \text{MINIMUM\_LIQUIDITY}

\]

其中 MINIMUM_LIQUIDITY = 1000(由 Uniswap V2 定义),这部分最小份额被永久锁定至地址 0 以消除除零风险。后续添加流动性时,则按存入比例铸造:

\[

\text{minted} = \frac{\Delta x}{x} \times \text{totalSupply}

\]

(实际实现中取按代币 A 和代币 B 计算出的较小值,多余代币会原路退回。)

当外部市场价格相对存入时刻发生偏离时,套利者会不断在池中交易,使合约内部价格向外部市场价格靠拢。这导致 LP 持有的资产比例被强制再平衡。数学上,若存入后价格变化比率为 rr,LP 相对于「简单持有(HODL)」的价值损失比例可近似为:

\[

\text{IL} = \frac{2\sqrt{r}}{1+r} - 1

\]

这就是著名的无常损失(Impermanent Loss, IL)。好消息是,每笔交易 0.3% 的手续费不会直接分配给 LP,而是直接累积到池中——表现为 kk 值随时间缓慢增长。只要手续费收益超过无常损失,LP 的净收益仍可为正。手续费收益是 LP 承担价格风险的补偿机制。

本节要点小结

  • x×y=kx \times y = k 是 CPMM 的唯一核心约束,所有价格与滑点均可由该公式推演。
  • 边际价格 P=y/xP = y / x 让合约在无外部喂价的情况下自动完成价格发现。
  • LP 面临无常损失,但手续费累积的 kk 增长是其风险补偿来源。

19.2 流动性池合约

19.2.1 添加流动性:接口与决策流程

在 Uniswap V2 风格的设计中,流动性由 Router 合约代理用户与底层 Pair 合约交互。addLiquidity 的函数签名如下:

solidity
function addLiquidity(
    address tokenA,
    address tokenB,
    uint256 amountADesired,
    uint256 amountBDesired,
    uint256 amountAMin,
    uint256 amountBMin,
    address to,
    uint256 deadline
) external returns (uint256 amountA, uint256 amountB, uint256 liquidity);

其核心决策流程可用 Mermaid 流程图表示:

flowchart TD
    A[用户调用 addLiquidity] --> B{池是否已存在?}
    B -->|reserve0 == 0 <br/> 首次添加| C[LP Token = sqrt(amountA * amountB) - 1000 <br/> 设定初始比率]
    B -->|reserve0 > 0 <br/> 已存在池| D[按当前储备计算 optimalB = <br/> amountADesired * reserve1 / reserve0]
    D --> E{optimalB <= amountBDesired?}
    E -->|是| F[存入 amountA = amountADesired <br/> amountB = optimalB]
    E -->|否| G[存入 amountB = amountBDesired <br/> 反向计算 optimalA]
    C --> H[amountA >= amountAMin? <br/> amountB >= amountBMin?]
    F --> H
    G --> H
    H -->|否| I[revert: 滑点超限]
    H -->|是| J[从用户转入代币到 Pair]
    J --> K[铸造 LP Token 给地址 to]
    K --> L[_update 更新储备量 <br/> 触发 Sync 事件]

关键安全设计

  1. 地址排序:调用前必须校验 tokenA < tokenB,保证同一交易对不会因参数顺序不同而生成重复池子。
  2. 最小值参数(amountAMin / amountBMin):防止 MEV 抢跑者(Front-runner)在用户交易Pending期间操控池价,导致用户以劣于预期的比率存入。若实际比率低于用户容忍下限,交易回滚。
  3. 截止时间(deadline):避免交易在内存池中挂起过久,在极端行情下被矿工在数小时后执行。

19.2.2 移除流动性:赎回与销毁

solidity
function removeLiquidity(
    address tokenA,
    address tokenB,
    uint256 liquidity,
    uint256 amountAMin,
    uint256 amountBMin,
    address to,
    uint256 deadline
) external returns (uint256 amountA, uint256 amountB);

移除流动性的核心逻辑是按 LP Token 份额比例赎回两种储备资产:

\[

\text{amountA} = \frac{\text{liquidity}}{\text{totalSupply}} \times \text{reserve0}, \quad

\text{amountB} = \frac{\text{liquidity}}{\text{totalSupply}} \times \text{reserve1}

\]

合约先销毁用户的 LP Token(内部状态变更),再通过 safeTransfer 将对应代币转出——严格遵循「检查-生效-交互」(Checks-Effects-Interactions)模式,避免重入攻击。实际赎回数量同样不得低于 amountAMinamountBMin,否则回滚。移除后需调用 _update() 同步储备量并触发 Sync 事件,供链下索引器重建状态。

19.2.3 完整 Pair 合约架构:最小化实现

以下是仿制 Uniswap V2 的最简 Pair 合约骨架,包含 mint()burn()、核心数学库函数及关键状态变量:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";

contract Pair is ERC20 {
    // ---------- 常量 ----------
    uint256 public constant MINIMUM_LIQUIDITY = 1000;
    // 手续费 0.3% 用 3/1000 表示
    uint256 public constant FEE_DENOMINATOR = 1000;
    uint256 public constant FEE_NUMERATOR  = 3;

    // ---------- 状态变量 ----------
    address public token0;
    address public token1;
    uint256 public reserve0;          // token0 储备量(含缓存)
    uint256 public reserve1;          // token1 储备量(含缓存)
    uint32  public blockTimestampLast; // 上次更新时间戳(用于 TWAP 预言机)
    uint256 public price0CumulativeLast;
    uint256 public price1CumulativeLast;

    // ---------- 事件 ----------
    event Mint(address indexed sender, uint256 amount0, uint256 amount1);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
    event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to);
    event Sync(uint256 reserve0, uint256 reserve1);

    constructor(address _token0, address _token1) ERC20("LP-Token", "LP") {
        token0 = _token0;
        token1 = _token1;
    }

    // ---------- 核心:_update 同步储备 ----------
    function _update(uint256 balance0, uint256 balance1) private {
        uint32 blockTimestamp = uint32(block.timestamp % 2**32);
        uint32 timeElapsed  = blockTimestamp - blockTimestampLast;
        if (timeElapsed > 0 && reserve0 != 0 && reserve1 != 0) {
            // 时间加权累计价格(TWAP 基础)
            price0CumulativeLast += uint256(reserve1) * timeElapsed / reserve0;
            price1CumulativeLast += uint256(reserve0) * timeElapsed / reserve1;
        }
        reserve0 = balance0;
        reserve1 = balance1;
        blockTimestampLast = blockTimestamp;
        emit Sync(reserve0, reserve1);
    }

    // ---------- 添加流动性:mint ----------
    function mint(address to) external returns (uint256 liquidity) {
        (uint256 _reserve0, uint256 _reserve1) = (reserve0, reserve1);
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 amount0  = balance0 - _reserve0;
        uint256 amount1  = balance1 - _reserve1;

        uint256 _totalSupply = totalSupply();
        if (_totalSupply == 0) {
            // 首次添加流动性
            liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
            _mint(address(0), MINIMUM_LIQUIDITY); // 永久锁定
        } else {
            // 按比例铸造,取较小值保证不被套利
            liquidity = Math.min(
                (amount0 * _totalSupply) / _reserve0,
                (amount1 * _totalSupply) / _reserve1
            );
        }
        require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED");
        _mint(to, liquidity);
        _update(balance0, balance1);
        emit Mint(msg.sender, amount0, amount1);
    }

    // ---------- 移除流动性:burn ----------
    function burn(address to) external returns (uint256 amount0, uint256 amount1) {
        uint256 _totalSupply = totalSupply();
        uint256 liquidity    = balanceOf(address(this)); // 用户先转入 LP Token
        require(liquidity > 0 && _totalSupply > 0, "NO_LIQUIDITY");

        amount0 = (liquidity * reserve0) / _totalSupply;
        amount1 = (liquidity * reserve1) / _totalSupply;
        require(amount0 > 0 && amount1 > 0, "INSUFFICIENT_LIQUIDITY_BURNED");

        _burn(address(this), liquidity);
        _safeTransfer(token0, to, amount0);
        _safeTransfer(token1, to, amount1);

        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1, to);
    }

    // ---------- swap 骨架(含手续费) ----------
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
        require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT");

        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 _reserve0 = reserve0;
        uint256 _reserve1 = reserve1;

        require(amount0Out < balance0 && amount1Out < balance1, "INSUFFICIENT_LIQUIDITY");

        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

        uint256 balance0Adjusted = balance0 * 1000 - amount0Out * 3;
        uint256 balance1Adjusted = balance1 * 1001 - amount1Out * 3;
        // 延至 19.3 展开完整输入量校验与 k 值检查

        // 占位:实际需校验 (balance0 * balance1) >= (_reserve0 * _reserve1)
        _update(
            IERC20(token0).balanceOf(address(this)),
            IERC20(token1).balanceOf(address(this))
        );
        emit Swap(msg.sender, 0, 0, amount0Out, amount1Out, to);
    }

    // ---------- 库函数:含 0.3% 手续费的输出量计算 ----------
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        public pure returns (uint256 amountOut)
    {
        require(amountIn > 0, "INSUFFICIENT_INPUT");
        require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");
        uint256 amountInWithFee = amountIn * 997; // 扣除 0.3% 手续费
        uint256 numerator   = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;
        amountOut = numerator / denominator;
    }

    // ---------- 安全转账 ----------
    function _safeTransfer(address token, address to, uint256 amount) private {
        (bool success, bytes memory data) = token.call(
            abi.encodeWithSelector(IERC20.transfer.selector, to, amount)
        );
        require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
    }
}

代码要点解读

  1. LP Token 即 ERC-20Pair 合约自身继承 ERC20,LP 份额本身是可转移、可交易的代币。
  2. _update() 的关键作用:每次储备量变更后调用,同步缓存的 reserve0/1 并更新 TWAP 累计价格。这些累计值可供外部预言机按时间窗口求平均,获得不易被闪电贷操纵的参考价格。
  3. 含手续费的输出公式:实际链上 getAmountOut 中,Δx\Delta x 先乘以 997(即扣除 0.3% 手续费后的 99.7%),再代入恒定乘积公式:

\[

\Delta y = \frac{y \times (\Delta x \times 997)}{(x \times 1000) + (\Delta x \times 997)}

\]

  1. 安全防护模式
  • 地址排序防重复池:由上层 Factory 合约在创建 Pair 时强制 token0 < token1
  • 极小值参数(min 参数)防抢跑:Router 合约在调用 Pair 前校验。
  • 检查-生效-交互模式:burn 先销毁 LP Token(状态变更),再转出代币(外部调用)。

本节要点小结

  • addLiquidity 通过 amountAMin / amountBMindeadline 保护用户免受滑点与抢跑。
  • mint()burn() 的份额计算确保 LP 始终按比例持有池中资产,首次流动性扣除 1000 个最小锁定份额。
  • _update() 与 TWAP 累计价格是链上价格预言机的基础,将在 19.3 节 swap 安全中发挥关键作用。

19.3 代币交换(Swap)与手续费用

自动做市商(AMM)的核心功能是让用户在不依赖订单簿的情况下,直接与流动性池进行代币交换。本节从恒定乘积公式出发,推导含手续费的 Swap 数学原理,并给出完整的 Solidity 实现。

19.3.1 恒定乘积公式在 Swap 场景下的变体

回忆 19.1 中建立的恒定乘积公式:

x×y=kx \times y = k

其中 xy 分别是池中两种代币的储备量,k 为常数。该公式的几何直观是一条双曲线:池子越深(TVL 越大),曲线越平缓,单笔交易的滑点越低。

无手续费的理想 Swap:假设用户向池子输入 Δx 个代币 X,期望获得 Δy 个代币 Y。交换完成后,池中 X 的储备变为 x + Δx,Y 的储备变为 y - Δy。由于乘积恒定:

(x+Δx)×(yΔy)=k(x + \Delta x) \times (y - \Delta y) = k

代入 k = x \times y 可解出 Δy

Δy=ykx+Δx=yx×yx+Δx=y×Δxx+Δx\Delta y = y - \frac{k}{x + \Delta x} = y - \frac{x \times y}{x + \Delta x} = \frac{y \times \Delta x}{x + \Delta x}

数值演示:假设池中有 100 ETH 和 300,000 USDC(x=100, y=300000, k=30,000,000)。用户输入 Δx=1 ETH:

Δy=300000×1100+12970.30\Delta y = \frac{300000 \times 1}{100 + 1} \approx 2970.30

用户获得约 2,970.30 USDC。交换后新储备:x'=101, y'=297029.70, k'=101 × 297029.70 ≈ 30,000,000k 保持不变。

19.3.2 手续费(0.3%)的数学处理与池子增值效应

在 Uniswap V2 中,每笔 Swap 收取 0.3% 的手续费。手续费不直接分配给流动性提供者(LP),而是留在池中,使恒定乘积常数 k 缓慢增大——这是 AMM 最核心的自动复利设计。

含手续费的恒定乘积更新公式:实际进入池子的有效输入为 Δx × (1 - f),其中 f = 0.003。交易后更新的恒定乘积为:

(x+Δx×(1f))×(yΔy)=x×y(x + \Delta x \times (1 - f)) \times (y - \Delta y) = x \times y

从该公式可解出含手续费的 Δy

Δy=yx×yx+Δx×(1f),f=0.003\Delta y = y - \frac{x \times y}{x + \Delta x \times (1 - f)}, \quad f = 0.003

手续费进入池子后,新的恒定乘积大于原值:

k=(x+Δx)×(yΔy)>x×y=kk' = (x + \Delta x) \times (y - \Delta y) > x \times y = k

数值验证:仍用上面 100 ETH / 300,000 USDC 的池子。用户输入 Δx=1 ETH,有效输入为 1 × (1 - 0.003) = 0.997 ETH:

Δy=300000100×300000100+0.997300000297029.112970.89\Delta y = 300000 - \frac{100 \times 300000}{100 + 0.997} \approx 300000 - 297029.11 \approx 2970.89

对比无手续费场景(2,970.30 USDC),含手续费的输出略少(2,970.89 USDC 中的差值即手续费部分)。交换后 k' = 101 × 297029.11 ≈ 30,000,940k 从 30,000,000 增大到约 30,000,940 —— 这 940 的增量即是 LP 全体共享的增值。

连续多笔交易后,k 单调增长,每个 LP token 对应的底层资产价值也随之增加。

19.3.3 价格冲击与滑点

价格冲击(Price Impact):单笔大额交易导致 AMM 曲线上即时价格偏移的幅度。数学定义为:

Price Impact=PafterPbeforePbefore=yΔyx+Δxyxyx\text{Price Impact} = \frac{P_{\text{after}} - P_{\text{before}}}{P_{\text{before}}} = \frac{\frac{y - \Delta y}{x + \Delta x} - \frac{y}{x}}{\frac{y}{x}}

价格冲击与池子深度(TVL)成反比——小池子里一笔不大的交易就可能产生显著的冲击。

滑点(Slippage):交易执行价与用户下单时预期价之间的偏差。滑点有两个来源:一是价格冲击(数学必然),二是区块确认延迟期间被其他交易(如 MEV 三明治攻击)抢先导致价格偏移。

  • 前端滑点设置档位:0.5%、1%、2% 等,用户根据交易紧急程度选择
  • 为什么小池子(低 TVL)滑点更大:因为同样的 Δx 在低 TVL 池中占比更大,价格冲击更剧烈

19.3.4 滑点保护的合约实现

Uniswap V2 风格的 Swap 函数接口:

solidity
function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path,
    address to,
    uint256 deadline
) external returns (uint256[] memory amounts);

两层保护机制

  1. 最小输出保护require(actualAmountOut >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT") —— 如果实际获得的代币少于用户设定的最低值,整笔交易回滚
  2. 过期保护require(block.timestamp <= deadline, "EXPIRED") —— 如果交易在截止时间后被执行,回滚
sequenceDiagram
    participant User as 用户
    participant Router as 路由合约
    participant Pair as Pair池合约
    participant Token0 as 代币X
    participant Token1 as 代币Y

    User->>Router: swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline)
    Router->>Router: 验证deadline未过期
    Router->>Token0: transferFrom(user, pair, amountIn)
    Router->>Pair: swap(amountOut, to, data)
    Pair->>Pair: 计算feeAmount = amountIn * 0.003
    Pair->>Pair: 计算actualOut = getAmountOut(amountIn - feeAmount, reserve0, reserve1)
    Pair->>Pair: require(actualOut >= amountOutMin)
    Pair->>Pair: 更新reserve0, reserve1
    Pair->>Token1: transfer(to, actualOut)
    Pair->>Pair: 触发Swap事件
    Pair-->>Router: 返回actualOut
    Router-->>User: 返回输出量

19.3.5 完整 Solidity 实现

以下是含 0.3% 手续费与滑点保护的 Swap 核心函数实现:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimplePair {
    IERC20 public token0;
    IERC20 public token1;

    uint256 public reserve0;
    uint256 public reserve1;

    uint256 public kLast; // 记录上次更新时的 k 值

    event Swap(
        address indexed sender,
        uint256 amount0In,
        uint256 amount1In,
        uint256 amount0Out,
        uint256 amount1Out,
        address indexed to
    );

    constructor(address _token0, address _token1) {
        token0 = IERC20(_token0);
        token1 = IERC20(_token1);
    }

    function _update(uint256 bal0, uint256 bal1) private {
        reserve0 = bal0;
        reserve1 = bal1;
    }

    /// @dev 安全乘除,返回 (x * y) / denominator,无溢出
    function _mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256) {
        return (x * y) / denominator; // Solidity 0.8+ 自动溢出回滚
    }

    /// @dev 给定输入量,计算含 0.3% 手续费的输出量
    function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        public
        pure
        returns (uint256 amountOut)
    {
        require(amountIn > 0, "INSUFFICIENT_INPUT_AMOUNT");
        require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");

        uint256 amountInWithFee = amountIn * 997; // 997/1000 = 1 - 0.003
        uint256 numerator = amountInWithFee * reserveOut;
        uint256 denominator = reserveIn * 1000 + amountInWithFee;
        amountOut = numerator / denominator;
    }

    /// @dev 执行代币交换
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
        require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT_AMOUNT");
        require(amount0Out < reserve0 && amount1Out < reserve1, "INSUFFICIENT_LIQUIDITY");

        // 读取当前余额(快照模式)
        uint256 balance0 = token0.balanceOf(address(this));
        uint256 balance1 = token1.balanceOf(address(this));

        // 检查 k 值是否单调递增(防操纵保护)
        if (kLast > 0) {
            uint256 currentK = balance0 * balance1;
            require(currentK >= kLast, "K");
        }
        kLast = balance0 * balance1;

        // 发送输出代币
        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

        // 计算实际输入(通过余额差推断)
        balance0 = token0.balanceOf(address(this));
        balance1 = token1.balanceOf(address(this));

        uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
        uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;

        require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT_AMOUNT");

        // 更新储备量
        _update(balance0, balance1);

        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

    function _safeTransfer(IERC20 token, address to, uint256 value) private {
        (bool success, bytes memory data) = address(token).call(
            abi.encodeWithSelector(token.transfer.selector, to, value)
        );
        require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
    }
}

swapExactTokensForTokens 路由函数(Router 合约中):

solidity
function swapExactTokensForTokens(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path,
    address to,
    uint256 deadline
) external returns (uint256[] memory amounts) {
    require(block.timestamp <= deadline, "EXPIRED");

    amounts = new uint256[](path.length);
    amounts[0] = amountIn;

    for (uint256 i = 0; i < path.length - 1; i++) {
        (address input, address output) = (path[i], path[i + 1]);
        (address token0, ) = sortTokens(input, output);
        SimplePair pair = SimplePair(getPair(input, output));

        (uint256 reserve0, uint256 reserve1) = pair.getReserves();
        (uint256 reserveIn, uint256 reserveOut) = input == token0
            ? (reserve0, reserve1) : (reserve1, reserve0);

        amounts[i + 1] = pair.getAmountOut(amounts[i], reserveIn, reserveOut);
        require(amounts[i + 1] >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT");

        (uint256 amount0Out, uint256 amount1Out) = input == token0
            ? (uint256(0), amounts[i + 1]) : (amounts[i + 1], uint256(0));

        pair.swap(amount0Out, amount1Out, to);
    }
}

19.3 要点总结

  • AMM 的 Swap 基于恒定乘积公式,手续费 f=0.003k 单调递增,LP 被动受益
  • 滑点保护通过 minAmountOut(合约层)和 deadline(时间层)双层防护
  • getAmountOut 算法使用 997/1000 的比例因子,将手续费处理嵌入输出计算

19.4 价格预言与闪电贷安全预防

在 DeFi 中,价格预言机(Price Oracle)是最关键也最脆弱的安全环节。本节从闪电贷攻击案例出发,推导时间加权平均价(TWAP)作为防御手段的原理与最小实现。

19.4.1 内部价格为何可被操纵

理解闪电贷:闪电贷(Flash Loan)无需抵押,只要在同一笔交易中归还即可。它让任何人瞬间借入巨额资金。

攻击链还原

sequenceDiagram
    participant Attacker as 攻击者
    participant FlashLoan as 闪电贷协议
    participant PoolA as AMM池A
    participant Target as 目标协议(借贷)

    Attacker->>FlashLoan: 闪电贷借入1000万tokenA
    FlashLoan-->>Attacker: 获得1000万tokenA
    Attacker->>PoolA: 大幅swap A→B(价格扭曲100x)
    PoolA-->>Attacker: 获得B(此时PoolA价格严重偏离)
    Attacker->>Target: 调用借贷/清算函数,读取PoolA瞬价
    Target->>PoolA: getReserves() → 读取被扭曲的价格
    Target-->>Attacker: 允许超额借贷/触发不合理的清算
    Attacker->>PoolA: 反向swap B→A(恢复价格)
    Attacker->>FlashLoan: 归还1000万tokenA + 手续费
    Attacker-->>Attacker: 净赚利润(Gas成本之外)

攻击者仅需支付 Gas 费和一次 Swap 手续费。无本金门槛即可对大资金协议发起攻击。

真实案例

  • Cream Finance(2021年):闪电贷操纵 AMP 价格,盗取约 1.3 亿美元
  • bZx(2020年):闪电贷组合攻击,利用合成资产借贷协议的预言机依赖
  • Harvest Finance(2020年):通过 QuickSwap 价格操纵盗取 2400 万 USDC

19.4.2 防御的第一原则:不使用单池瞬价

单池瞬价(Spot Price) 的脆弱性在于它只代表当前时刻单个 AMM 池的价格。一次大额交易即可将其推至极端值。

防御策略分层(从弱到强):

方案操纵成本延迟准确性外部依赖
单池瞬价极低(一次交易)
多池加权中等(需同时操纵多池)较高
TWAP(本章实现)高(需多区块维持)中等
Chainlink 外源预言机极高(需攻击链下数据源)

为什么 TWAP 是本章的最佳选择:TWAP 无需外部依赖,直接在 AMM 合约内部实现,成本可控,攻击经济门槛提升显著。

19.4.3 TWAP 的数学原理

连续时间定义:时间加权平均价是对价格函数在时间区间上的积分平均:

PTWAP=1t2t1t1t2P(t)dtP_{\text{TWAP}} = \frac{1}{t_2 - t_1} \int_{t_1}^{t_2} P(t) \, dt

离散区块实现:区块链上无法连续积分,因此采用累计价格方案——在每个区块更新时对价格做时间累加。

Uniswap V2 的累计价格机制

每当储备量变更(swap / mint / burn)时,更新累计价格:

price0Cumulativenew=price0Cumulativeold+reserve1reserve0×Δt\text{price0Cumulative}_{\text{new}} = \text{price0Cumulative}_{\text{old}} + \frac{\text{reserve1}}{\text{reserve0}} \times \Delta t

其中 reserve1/reserve0 = y/x 是更新前的即时价格比,Δt = blockTimestamp - blockTimestampLast 是自上次更新以来的秒数。

查询 TWAP:给定一个时间区间 [t1, t2]

PTWAP=priceCumulative(t2)priceCumulative(t1)t2t1P_{\text{TWAP}} = \frac{\text{priceCumulative}(t_2) - \text{priceCumulative}(t_1)}{t_2 - t_1}

为什么 TWAP 是安全的:攻击者若要在 TWAP 上制造足够的价格偏移,必须在多个连续区块中维持扭曲的价格。操纵成本估算:

CostΔPP×TVL×TwindowTblock\text{Cost} \approx \frac{\Delta P}{P} \times \text{TVL} \times \frac{T_{\text{window}}}{T_{\text{block}}}

例如:要在一个 30 区块(~6 分钟)的时间窗口内保持 10% 的价格偏差,池子 TVL 为 1 亿美元,成本约为 0.1 × 1亿 × 30 = 3亿(需持续输入/输出资金维持偏离)。相比之下,单区块瞬价操纵的成本仅为一个区块的 Swap 手续费。

19.4.4 TWAP 的局限性与折中

  • 时滞性:TWAP 反映的是历史平均价。在高波动行情中,链上价格已剧烈变化,但 TWAP 仍停留在旧区间,可能产生延迟套利机会
  • 适用范围:Uniswap V2 默认不建议用 TWAP 做抵押品定价;更适合做低风险场景的参考价或安全检查门(如保证清算价格不低于 n 区块平均价)
  • 替代方案:Uniswap V3 的 TWAP Oracle 支持从出块者那里注入更精确的价格,但复杂度更高;主流协议多采用 Chainlink 为主、TWAP 为辅的混合方案

19.4.5 最小代码实现

在 Pair 合约的每次状态变更后调用 _update,累计时间加权价格:

solidity
// ==== 状态变量 ====

uint256 public price0Cumulative;
uint256 public price1Cumulative;
uint32 public blockTimestampLast;

/// @dev UQ112x112 定点数缩放因子
uint256 private constant Q112 = 2**112;

// ==== 核心更新函数 ====

function _update(uint256 balance0, uint256 balance1, uint32 _reserve0, uint32 _reserve1) private {
    // 计算自上次更新以来的时间差(秒)
    uint32 blockTimestamp = uint32(block.timestamp % 2**32);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 首次可为0

    if (timeElapsed > 0 && _reserve0 > 0 && _reserve1 > 0) {
        // 累计价格更新(UQ112x112 精度)
        // price0Cumulative: 以 token1 计价的 token0 价格的累计和
        price0Cumulative += uint256(_reserve1) * timeElapsed / _reserve0;
        // price1Cumulative: 以 token0 计价的 token1 价格的累计和
        price1Cumulative += uint256(_reserve0) * timeElapsed / _reserve1;
    }

    reserve0 = balance0;
    reserve1 = balance1;
    blockTimestampLast = blockTimestamp;
}

// ==== 查询函数(供外部合约调用)====

function getReservesAndCumulative()
    external
    view
    returns (
        uint256 _reserve0,
        uint256 _reserve1,
        uint256 _price0Cumulative,
        uint256 _price1Cumulative,
        uint32 _blockTimestampLast
    )
{
    return (reserve0, reserve1, price0Cumulative, price1Cumulative, blockTimestampLast);
}

/// @dev 外部合约调用:计算指定周期的 TWAP
/// @param cumulativeStart 周期起始时的 priceCumulative
/// @param cumulativeNow   周期结束时的 priceCumulative
/// @param period          周期时长(秒)
/// @return twapPrice      时间加权平均价
function consult(
    uint256 cumulativeStart,
    uint256 cumulativeNow,
    uint256 period
) external pure returns (uint256 twapPrice) {
    require(period > 0, "PERIOD_ZERO");
    twapPrice = (cumulativeNow - cumulativeStart) / period;
}

精度说明:使用 UQ112x112 定点数格式(放大 2^112 倍存储),避免整数除法的小数截断误差。当 reserve0reserve1 都使用 uint112 类型时,乘积 reserve1 * timeElapsed 最大值仍在安全范围内,reserve1 / reserve0 的小数部分通过前移 112 位保留。

flowchart TD
    A[swap/mint/burn 触发] --> B{状态变更发生?}
    B -->|是| C[读取 block.timestamp]
    C --> D[计算 timeElapsed = now - blockTimestampLast]
    D --> E{timeElapsed > 0 && 有储备量?}
    E -->|是| F[price0Cumulative += reserve1 * dt / reserve0]
    F --> G[price1Cumulative += reserve0 * dt / reserve1]
    G --> H[更新 reserve0, reserve1]
    H --> I[更新 blockTimestampLast = now]
    E -->|否| H
    I --> J[结束]

19.4 要点总结

  • 闪电贷让单笔交易即可操纵 AMM 瞬价,但 TWAP 要求攻击者维持多区块扭曲,经济门槛呈线性增长
  • 累计价格机制(priceCumulative)是 Uniswap V2 的精华设计,实现了零外部依赖的去中心化价格预言
  • TWAP 并非万能:时滞性使其不适合高频定价场景,应与外源预言机组合使用

19.5 前端UI:钱包连接、交易面板、流动性管理

完成了19.1至19.4的合约层逻辑后,我们已掌握了AMM的经济内核。然而,仅有Solidity合约并不能让用户直接使用DEX。去中心化应用(DApp,Decentralized Application)的精髓在于:将链上逻辑通过前端界面呈现给真实用户。一个完整的DEX前端需要负责三件事:连接钱包、展示状态、构造并发送交易。本节将拆解DEX前端的核心组件,编写一个可运行的React + ethers.js交互面板。

19.5.1 架构概览与组件拆解

一个DEX前端可抽象为三层架构:

  • 钱包层(Wallet Provider):负责用户授权、账户管理、链切换。MetaMask通过浏览器注入 window.ethereum 对象,是最常见的钱包入口。
  • 合约交互层(ethers.js/viem):负责链上读取(call)与链上写入(sendTransaction)。ethers.js v6 是目前主流选择,提供 BrowserProviderContract 抽象。
  • UI状态层(React/Vue):负责界面渲染、用户输入校验、交易状态反馈。

核心组件清单如下:

  • WalletConnector:检测MetaMask存在,请求账户授权,监听 accountsChanged / chainChanged / disconnect 事件,并在状态变化时清理缓存。
  • TokenSelector:下拉选择代币,联动查询 balanceOfdecimals,支持自定义代币地址输入。
  • SwapPanel:输入/输出金额、实时兑换率、滑点容差(Slippage Tolerance)设置、价格影响(Price Impact)估算。
  • LiquidityPanel:添加与移除LP的成对输入框,展示当前LP Token余额与池子总流动性。
  • PriceChart(可选):展示基于池子储备变化的价格走势。

安全原则:前端仅仅是展示层,核心资产操作全部通过链上合约执行,私钥始终保存在MetaMask内部,绝不进入JavaScript内存。

19.5.2 钱包连接:MetaMask集成与事件处理

ethers.js v6 使用 ethers.BrowserProvider 替代了 v5 的 Web3Provider,调用方式如下:

javascript
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();

关键事件监听不可忽略:

  • accountsChanged:用户切换账户时,前端需重置所有余额与授权状态。
  • chainChanged:用户切换网络时,前端需校验链ID是否匹配合约部署网络(如Hardhat本地链ID 31337 或 Sepolia 11155111)。若不匹配,应提示用户切换或调用 wallet_switchEthereumChain
  • disconnect:钱包断开连接时清理状态,避免用户误以为仍在连接状态。

前端只需处理EOA(外部拥有账户,Externally Owned Account)的签名交互;合约账户(如多签钱包、智能合约钱包)的复杂逻辑通常由Router合约自动路由,前端无需特别处理。

19.5.3 交易面板Swap:实时计算、授权与交易构造

Swap是DEX最核心的用户交互。前端需要根据当前池子储备实时计算输出金额。

兑换率计算

不含手续费时,恒定乘积公式给出输出:

Δy=ykx+Δx\Delta y = y - \frac{k}{x + \Delta x}

其中 xxyy 为池子中两种代币的储备量,k=x×yk = x \times y 为常数。

包含0.3%手续费后,实际交互公式变为:

Δy=ykx+Δx×0.997\Delta y = y - \frac{k}{x + \Delta x \times 0.997}

这意味着用户实际支付的输入金额中,0.3%作为手续费计入池子,剩余的99.7%参与恒定乘积计算。

价格影响与滑点保护

价格影响(Price Impact)衡量用户交易额占池子深度的比例。若用户交易1,000 TTA,而池子仅有10,000 TTA储备,则价格影响约为10%,导致显著滑点。前端应在Swap面板实时显示这一指标,提示用户风险。

最小输出保护(Minimum Amount Out)是防止MEV夹心攻击的关键参数:

minAmountOut=expectedAmountOut×(1slippage)\text{minAmountOut} = \text{expectedAmountOut} \times (1 - \text{slippage})

前端通常允许用户设置0.5%或1%的滑点容差。若链上实际输出低于 minAmountOut,交易将revert(回滚),保护用户免受价格突变损失。

两步交易流程

由于ERC-20采用授权-转移(approve-transfer)模型,Swap需要两步:

  1. 授权(approve):用户先调用Token合约的 approve(routerAddress, amountIn),授权Router代为转移代币。前端需监听 Approval 事件确认授权生效。
  2. 执行Swap:用户调用Router的 swapExactTokensForTokens,传入 amountInminAmountOutpathtodeadline 等参数。

无限approve(type(uint256).max) vs 精确approve的争议:无限approve减少用户每次交易前的额外签名步骤,但一旦Router合约被攻击,用户全部余额将面临风险。精确approve更安全,但用户体验略差。前端应提供两种选项并明示风险。

19.5.4 流动性管理面板:添加与移除LP

添加流动性时,用户输入两种代币的数量。若用户输入的比例偏离当前池子储备比例,合约会自动取较小值配平,并退还多余的另一种代币。前端应实时查询 getReserves 并提示用户最优配平比例。

对于首次创建新交易对的用户(First LP),前端应特别提示:“你正在创建新交易对,你的输入将决定初始价格比率。” 此时LP Token总量按 Linitial=x0×y0L_{\text{initial}} = \sqrt{x_0 \times y_0} 铸造。

移除流动性时,用户输入LP Token数量,前端预估可赎回的两种代币数量:

可赎回A=reserveA×lpAmounttotalSupply\text{可赎回A} = \text{reserveA} \times \frac{lpAmount}{totalSupply}

状态刷新策略:每次交易后或每轮新区块产生时,重新调用 getReservesbalanceOf,确保展示数据与链上最新状态一致。

19.5.5 代码示例:React + ethers.js Hook封装

以下是一个可复用的自定义Hook useSwap,封装了核心交互逻辑:

javascript
import { useState, useCallback } from 'react';
import { ethers } from 'ethers';

const ROUTER_ABI = [
  "function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external",
];

const ERC20_ABI = [
  "function approve(address spender, uint256 amount) external",
  "function balanceOf(address account) external view returns (uint256)",
];

export function useSwap(routerAddress, tokenA, tokenB, reserveA, reserveB) {
  const [pending, setPending] = useState(false);

  const getAmountOut = useCallback((amountIn) => {
    const k = reserveA * reserveB;
    return reserveB - k / (reserveA + amountIn * 997n / 1000n);
  }, [reserveA, reserveB]);

  const executeSwap = useCallback(async (signer, amountIn, slippage = 0.005) => {
    setPending(true);
    try {
      const expectedOut = getAmountOut(amountIn);
      const minOut = expectedOut * BigInt(Math.floor((1 - slippage) * 1000)) / 1000n;
      const tokenContract = new ethers.Contract(tokenA, ERC20_ABI, signer);
      const router = new ethers.Contract(routerAddress, ROUTER_ABI, signer);
      const approveTx = await tokenContract.approve(routerAddress, amountIn);
      await approveTx.wait();
      const swapTx = await router.swapExactTokensForTokens(
        amountIn, minOut, [tokenA, tokenB], await signer.getAddress(),
        Math.floor(Date.now() / 1000) + 300
      );
      await swapTx.wait();
      return swapTx;
    } finally {
      setPending(false);
    }
  }, [getAmountOut, routerAddress, tokenA, tokenB]);

  return { getAmountOut, executeSwap, pending };
}

要点总结

  • DApp前端是钱包层、合约层与UI层的三层架构。
  • Swap输出通过恒定乘积公式实时计算,前端必须包含0.3%手续费的修正。
  • 最小输出保护(minAmountOut)是抵御MEV攻击的必备机制。
  • 流动性管理中,首次创建交易对的用户决定了初始价格比率。
flowchart TB
    A[连接钱包] --> B[选择代币A与B]
    B --> C[输入金额]
    C --> D[实时计算输出与价格影响]
    D --> E[授权Router转移代币]
    E --> F[调用swapExactTokensForTokens]
    F --> G[等待链上确认]
    G --> H[刷新余额与池子储备]

19.6 集成测试代币与端到端流程

前端开发需要可预测的链上环境。使用真实主网代币不仅昂贵,且无法精确控制初始储备量。本节将部署两个测试代币,通过完整的「添加流动性 → 执行Swap → 移除流动性」流程,验证整个DEX的经济逻辑是否自洽。

19.6.1 测试代币合约:TestTokenA 与 TestTokenB

借助OpenZeppelin的 ERC20PresetMinterPauser,我们可以在Hardhat网络中快速部署测试代币:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";

contract TestTokenA is ERC20PresetMinterPauser {
    constructor() ERC20PresetMinterPauser("TestTokenA", "TTA") {
        mint(msg.sender, 100000 * 10 ** decimals());
    }
}

contract TestTokenB is ERC20PresetMinterPauser {
    constructor() ERC20PresetMinterPauser("TestTokenB", "TTB") {
        mint(msg.sender, 100000 * 10 ** decimals());
    }
}

每个合约在部署时自动铸造100,000枚代币(精度18位),供测试使用。测试代币无价格风险,可无限重置,是本地开发与集成测试的黄金标准。

19.6.2 完整流程第一步:添加初始流动性

场景设定:用户A持有各10,000枚TTA与TTB,通过Router向Pair合约注入初始流动性。

javascript
// 部署后的交互流程(Hardhat脚本简化版)
await router.addLiquidity(
  tokenA.address, tokenB.address,
  ethers.parseUnits("10000", 18), ethers.parseUnits("10000", 18),
  0, 0, owner.address, deadline
);

验证要点:

  1. 调用 pair.getReserves(),池子储备应变为 (10000×1018,10000×1018)(10000 \times 10^{18}, 10000 \times 10^{18})
  2. LP Token总供应量等于 Linitial=x0×y0=1022×1022=1022L_{\text{initial}} = \sqrt{x_0 \times y_0} = \sqrt{10^{22} \times 10^{22}} = 10^{22}
  3. 用户A的LP Token余额等于总供应量,即拥有100%池子份额。

19.6.3 完整流程第二步:执行Swap并观察价格变化

场景:用户B用1,000 TTA兑换TTB,设定1%滑点容差。

含手续费后的精确输出计算:

Δy=ybeforexbefore×ybeforexbefore+Δx×(10.003)\Delta y = y_{\text{before}} - \frac{x_{\text{before}} \times y_{\text{before}}}{x_{\text{before}} + \Delta x \times (1 - 0.003)}

代入数值:

Δy=1000010000×1000010000+1000×0.997906.61\Delta y = 10000 - \frac{10000 \times 10000}{10000 + 1000 \times 0.997} \approx 906.61

Swap后池子储备变为 (11000,9093.39)(11000, 9093.39),新的价格比变为:

Pnew=110009093.391.21P_{\text{new}} = \frac{11000}{9093.39} \approx 1.21

这意味着TTA相对于TTB贬值了约21%。这是恒定乘积做市商的核心特征:大额交易会沿着价格曲线滑动,导致价格显著偏离。

验证 x×yx \times y:由于手续费0.3%计入了池子,新的乘积 11000×9093.39100,027,29011000 \times 9093.39 \approx 100,027,290,略大于原始 k=108k = 10^8。这部分增量即为LP赚取的手续费,使 kk 随时间缓慢增长。

19.6.4 完整流程第三步:移除流动性并暗示无常损失

用户A决定移除全部LP Token。根据当前储备比例,可赎回:

TTA=11000,TTB=9093.39\text{TTA} = 11000, \quad \text{TTB} = 9093.39

对比两种策略:

  • 做市结果(LP):持有11,000 TTA + 9,093.39 TTB
  • 单纯HODL:如果用户A从未提供流动性,仍持有10,000 TTA + 10,000 TTB

假设外部市场价格仍为1:1,则LP持仓总价值为 11000+9093.39=20093.3911000 + 9093.39 = 20093.39,而HODL总价值为 2000020000。表面上LP"赚"了手续费,但如果外部价格未变,LP实际上承受了无常损失(Impermanent Loss):其资产价值因池内价格偏离外部市场而下降。

19.8节将用精确公式 IL=2ρ/(1+ρ)1IL = 2\sqrt{\rho}/(1+\rho) - 1 量化这一损失。本节先建立直觉:无常损失不是"本金亏损",而是"相对于HODL的机会成本"。

19.6.5 Hardhat端到端测试脚本

以下脚本完整覆盖从部署到移除流动性的全流程,每个步骤附带断言与日志输出:

javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("DEX E2E Flow", function () {
  it("deploy → addLiquidity → swap → removeLiquidity", async () => {
    const [owner, userB] = await ethers.getSigners();
    
    // 1. 部署Factory、TokenA、TokenB、Pair、Router(示意)
    const Factory = await ethers.getContractFactory("Factory");
    const factory = await Factory.deploy();
    const TokenA = await ethers.getContractFactory("TestTokenA");
    const tokenA = await TokenA.deploy();
    const TokenB = await ethers.getContractFactory("TestTokenB");
    const tokenB = await TokenB.deploy();
    // ... 部署Router并创建Pair ...

    // 2. 添加初始流动性
    await tokenA.approve(router.target, ethers.parseUnits("10000", 18));
    await tokenB.approve(router.target, ethers.parseUnits("10000", 18));
    await router.addLiquidity(tokenA.target, tokenB.target, ...);
    console.log("Reserves after add:", await pair.getReserves());

    // 3. 用户B执行swap
    const amountIn = ethers.parseUnits("1000", 18);
    await tokenA.connect(userB).approve(router.target, amountIn);
    await router.connect(userB).swapExactTokensForTokens(
      amountIn, 0, [tokenA.target, tokenB.target], userB.address, deadline
    );
    console.log("UserB TTB balance:", await tokenB.balanceOf(userB.address));

    // 4. 移除流动性
    const lpBalance = await pair.balanceOf(owner.address);
    await pair.approve(router.target, lpBalance);
    await router.removeLiquidity(tokenA.target, tokenB.target, lpBalance, 0, 0, owner.address, deadline);
    console.log("Owner balances after remove:", await tokenA.balanceOf(owner.address), await tokenB.balanceOf(owner.address));
  });
});
sequenceDiagram
    actor UserA
    actor UserB
    participant Router
    participant Pair
    participant TokenA
    participant TokenB
    UserA->>Router: addLiquidity(10000A, 10000B)
    Router->>Pair: mint LP token
    Pair->>TokenA: transferFrom UserA
    Pair->>TokenB: transferFrom UserA
    UserB->>TokenA: approve(Router, 1000)
    UserB->>Router: swapExactTokensForTokens(1000A → ?B)
    Router->>Pair: swap(1000A, minB)
    Pair->>TokenA: transferFrom UserB
    Pair->>TokenB: transfer to UserB
    UserA->>Router: removeLiquidity(all LP)
    Router->>Pair: burn LP
    Pair->>TokenA: transfer to UserA
    Pair->>TokenB: transfer to UserA

要点总结

  • 测试代币(Mock Token)是本地开发DEX前端的必需品,OpenZeppelin的预设合约可快速部署。
  • 初始流动性注入后,LP Token按几何平均数 L=x0×y0L = \sqrt{x_0 \times y_0} 铸造。
  • Swap会改变池子储备比例,导致池内价格沿价格曲线滑动。
  • 无常损失的本质是「LP做市收益 vs 单纯HODL的机会成本」,与手续费收益相互抵消。

19.7 部署与验证全套合约

本地Hardhat网络完成了逻辑验证后,下一步是将合约部署到测试网乃至主网。本节介绍Factory + Pair + Router的最小部署方案,以及合约验证、前端地址配置的标准化流程。

19.7.1 Uniswap V2 最小三件套部署

Uniswap V2的核心由三件套组成:

  • Factory:管理所有交易对的创建,记录 tokenA → tokenB → pairAddress 映射。通过 createPair(address, address) 创建新交易对。
  • Pair:每个交易对对应一个Pair合约实例,持有双币储备、执行swap、发行LP Token。Pair代码通过CREATE2确定性部署,使得给定两个代币地址即可预测Pair地址。
  • Router:前端唯一交互入口,封装了复杂的参数计算(如配平amounts、最小数量保护、期限deadline)和重入保护。本章Router简化为仅支持单池swap和流动性管理,多hop路由可作为扩展练习。

部署顺序至关重要:Factory → 两个ERC-20 → 用Factory创建Pair → Router(传入Factory地址作为构造参数)。

19.7.2 Hardhat部署脚本与网络配置

以下是一个标准的 deploy.js

javascript
// scripts/deploy.js
const { ethers } = require("hardhat");
const fs = require("fs");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with:", deployer.address);

  const Factory = await ethers.getContractFactory("Factory");
  const factory = await Factory.deploy();
  await factory.waitForDeployment();
  console.log("Factory:", factory.target);

  const TokenA = await ethers.getContractFactory("TestTokenA");
  const tokenA = await TokenA.deploy();
  await tokenA.waitForDeployment();
  console.log("TokenA:", tokenA.target);

  const TokenB = await ethers.getContractFactory("TestTokenB");
  const tokenB = await TokenB.deploy();
  await tokenB.waitForDeployment();
  console.log("TokenB:", tokenB.target);

  const tx = await factory.createPair(tokenA.target, tokenB.target);
  await tx.wait();
  const pairAddress = await factory.getPair(tokenA.target, tokenB.target);
  console.log("Pair:", pairAddress);

  const Router = await ethers.getContractFactory("Router");
  const router = await Router.deploy(factory.target);
  await router.waitForDeployment();
  console.log("Router:", router.target);

  const addresses = {
    factory: factory.target,
    tokenA: tokenA.target,
    tokenB: tokenB.target,
    pair: pairAddress,
    router: router.target,
  };
  fs.writeFileSync("deployments.json", JSON.stringify(addresses, null, 2));
}

main().catch(console.error);

运行方式:

bash
npx hardhat run scripts/deploy.js --network sepolia

hardhat.config.js 的网络配置:

javascript
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");

module.exports = {
  solidity: "0.8.20",
  networks: {
    hardhat: { chainId: 31337 },
    sepolia: {
      url: process.env.SEPOLIA_RPC,
      accounts: [process.env.PRIVATE_KEY],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

安全提醒:部署私钥必须与前端交互账号分离。生产环境禁止在代码中硬编码私钥,应使用环境变量或专用部署CI/CD流水线。

19.7.3 合约验证与Etherscan源码验证

源码验证的意义在于:任何人可在Etherscan上直接读取合约状态、调用只读函数、查看ABI,从而极大增强信任度。Hardhat的Etherscan插件使验证自动化:

bash
npx hardhat verify --network sepolia <CONTRACT_ADDRESS> <CONSTRUCTOR_ARG1> <CONSTRUCTOR_ARG2>

常见验证失败原因:

  • 编译器版本与部署时不一致。
  • 优化设置(runs参数)不匹配。
  • 构造函数参数ABI编码错误。
  • 合约地址已被其他代码占用(常见于测试网地址复用)。

多链验证需切换API Key:主网Etherscan、Sepolia、BscScan、PolygonScan等扫描器各自独立。

19.7.4 前端配置与合约地址管理

部署完成后,前端需要一份多网络地址映射表:

javascript
// src/config/contracts.js
export const CONTRACTS = {
  31337: {  // Hardhat local
    factory: "0x...",
    router: "0x...",
    pair: "0x...",
    tokenA: "0x...",
    tokenB: "0x...",
  },
  11155111: {  // Sepolia
    factory: "0x...",
    router: "0x...",
    pair: "0x...",
    tokenA: "0x...",
    tokenB: "0x...",
  },
};

ABI文件从 artifacts/contracts/ 提取所需接口,放入前端 abis/ 目录。建议仅保留function和event定义,去除bytecode字段以减小打包体积。

合约重部署后必须同步更新前端地址映射,避免用户调用旧合约导致失败。建议将部署地址归档至版本控制(如 deployments/<chainId>.json)。

flowchart LR
    A[编译合约] --> B[部署Factory/ERC-20/Pair/Router]
    B --> C[写入deployments.json]
    C --> D[Etherscan源码验证]
    D --> E[提取ABI至前端]
    E --> F[配置多网络地址映射]
    F --> G[前端连接测试]

要点总结

  • Factory + Pair + Router 的最小部署顺序不可颠倒。
  • Hardhat部署脚本应自动将地址导出为JSON,供前端和测试复用。
  • Etherscan源码验证是增强合约可信度的必要步骤。
  • 多网络地址映射(按chainId组织)是保障用户体验的基础设施。

19.8 测试套件与无常损失计算

从19.1的数学建模到19.7的链上部署,我们已经完成了一个简易DEX的全链路工程。然而,合约一旦部署即不可更改,任何经济逻辑上的漏洞都将造成真实的资产损失。19.8节将回到测试的视角,用Hardhat测试套件从数学上严格验证三项核心假设:恒定乘积是否成立、Swap输出是否精确、无常损失是否可被公式量化。这是整个第19章的最后一道质量关卡。

19.8.1 为什么要写测试套件

智能合约的测试不仅是"代码是否能跑通",更是"经济学假设是否成立"。在传统Web开发中,我们可以通过热补丁(hotfix)修复线上bug;在区块链上,合约状态不可篡改,任何数学漏洞都会导致真实资金损失。

测试分为三个层次:

  • 单元测试(Unit Test):验证单个函数的边界行为,如 getAmountOut(0) 应返回0,addLiquidity 在首次注入时LP Token总量是否正确。
  • 集成测试(Integration Test):验证多个合约之间的交互,如Factory创建Pair后Router是否能正确路由。
  • 端到端测试(E2E Test):模拟真实用户场景,从部署到Swap到移除流动性的完整资产流动闭环。

Hardhat测试框架提供 loadFixtureevm_snapshot / evm_revert,使我们能在每个测试用例之间快速重置链状态,同时又能构造复杂的时间序列场景。

19.8.2 测试用例一:初始流动性后k值验证

场景:向空池子注入10,000 TTA + 10,000 TTB,验证储备量与LP Token总量。

javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");

it("初始流动性注入后储备与LP总量验证", async () => {
  const [owner] = await ethers.getSigners();
  const amount = ethers.parseUnits("10000", 18);

  // 授权并添加流动性
  await tokenA.approve(router.target, amount);
  await tokenB.approve(router.target, amount);
  await router.addLiquidity(tokenA.target, tokenB.target, amount, amount, 0, 0, owner.address, deadline);

  const [reserve0, reserve1] = await pair.getReserves();
  expect(reserve0).to.equal(amount);
  expect(reserve1).to.equal(amount);

  const totalSupply = await pair.totalSupply();
  const expectedLP = ethers.parseUnits("10000", 18); // sqrt(10^22 * 10^22) / 10^18 = 10^22 / 10^18 = 10^4 (scaled by 10^18)
  expect(totalSupply).to.equal(expectedLP);

  const k = reserve0 * reserve1;
  const expectedK = amount * amount;
  expect(k).to.equal(expectedK);
});

要点:首次注入时LP Token按 Linitial=x0×y0L_{\text{initial}} = \sqrt{x_0 \times y_0} 铸造。当 x0=y0=1022x_0 = y_0 = 10^{22}(含18位精度)时,L=1022L = 10^{22},以18位精度表示即为 104×1018=102210^{4} \times 10^{18} = 10^{22}

19.8.3 测试用例二:精确Swap计算验证

场景:向已有池子(10000, 10000)输入1,000 TTA,验证链上输出与数学公式是否一致。

含手续费的精确输出公式:

Δy=ybeforexbefore×ybeforexbefore+Δx×0.997\Delta y = y_{\text{before}} - \frac{x_{\text{before}} \times y_{\text{before}}}{x_{\text{before}} + \Delta x \times 0.997}

代入数值:

Δy=1000010000×1000010000+1000×0.997906.61\Delta y = 10000 - \frac{10000 \times 10000}{10000 + 1000 \times 0.997} \approx 906.61
javascript
it("Swap输出与数学公式一致", async () => {
  // ... 已添加初始流动性

  const amountIn = ethers.parseUnits("1000", 18);
  const reserveBefore = await pair.getReserves();

  // 链上执行swap(无滑点保护,仅用于验证)
  await tokenA.approve(router.target, amountIn);
  await router.swapExactTokensForTokens(
    amountIn, 0, [tokenA.target, tokenB.target], owner.address, deadline
  );

  const reserveAfter = await pair.getReserves();
  const actualOut = reserveBefore[1] - reserveAfter[1];

  // 数学计算(BigInt整数运算,允许1 wei舍入误差)
  const amountInWithFee = amountIn * 997n / 1000n;
  const numerator = reserveBefore[0] * reserveBefore[1];
  const denominator = reserveBefore[0] + amountInWithFee;
  const expectedOut = reserveBefore[1] - numerator / denominator;

  // 允许最多2 wei的舍入差异
  const diff = actualOut > expectedOut ? actualOut - expectedOut : expectedOut - actualOut;
  expect(diff).to.be.lte(2n);
});

这一测试的意义在于:合约的Solidity实现与我们的数学模型至少在数值上是等价的。如果测试失败,说明合约中的fee处理或除法舍入策略存在问题。

19.8.4 测试用例三:无常损失量化测试

场景:LP以1:1价格注入流动性(10,000 TTA + 10,000 TTB),随后大额交易将池内价格从1:1推至1:2,LP移除全部流动性并计算损失。

无常损失公式推导

当价格从 P0P_0 变为 P1P_1 时,定义价格比率 ρ=P1/P0\rho = P_1 / P_0。LP提取时的资产数量为:

x=Lρ,y=L/ρx' = L \cdot \sqrt{\rho}, \quad y' = L / \sqrt{\rho}

其中 LL 为LP Token总量(保持守恒)。无常损失定义为LP资产价值与HODL策略的价值差异百分比:

IL=2ρ1+ρ1IL = \frac{2\sqrt{\rho}}{1 + \rho} - 1

ρ=2\rho = 2(价格翻倍)时:

IL=221+21=2×1.4142310.0571=5.71%IL = \frac{2\sqrt{2}}{1 + 2} - 1 = \frac{2 \times 1.4142}{3} - 1 \approx -0.0571 = -5.71\%

这意味着:如果LP单纯"持有不动",资产价值会比HODL策略低约5.71%。注意这是"相对于HODL的机会成本",而非绝对亏损。如果手续费收益超过5.71%,LP仍然是盈利的。

Hardhat 测试脚本

javascript
it("无常损失量化:价格1→2时损失约5.71%", async () => {
  const [owner, trader] = await ethers.getSigners();
  const tenK = ethers.parseUnits("10000", 18);

  // 1. LP注入流动性(1:1)
  await tokenA.approve(router.target, tenK);
  await tokenB.approve(router.target, tenK);
  await router.addLiquidity(tokenA.target, tokenB.target, tenK, tenK, 0, 0, owner.address, deadline);

  // 2. 交易者用大量TTA买入TTB,将价格从1:1推至1:2
  // 根据 AMM 公式,要使价格变为1:2,需要在池中有大约7071 TTA和14142 TTB(含手续费修正)
  // 这里通过反复swap使池到达目标比例,实际数值由脚本迭代确定
  const largeAmount = ethers.parseUnits("100000", 18);
  await tokenA.connect(trader).approve(router.target, largeAmount);
  // 实际测试中会找到使价格比恰好达到1:2的输入量,此处示意
  // ...

  // 3. LP移除流动性
  const lpBalance = await pair.balanceOf(owner.address);
  await pair.approve(router.target, lpBalance);
  const beforeA = await tokenA.balanceOf(owner.address);
  const beforeB = await tokenB.balanceOf(owner.address);
  await router.removeLiquidity(tokenA.target, tokenB.target, lpBalance, 0, 0, owner.address, deadline);
  const afterA = await tokenA.balanceOf(owner.address);
  const afterB = await tokenB.balanceOf(owner.address);

  const retrievedA = afterA - beforeA;
  const retrievedB = afterB - beforeB;

  // 4. 计算价值(假设外部市场仍为1:2,以TTB为计价单位)
  const lpValue = retrievedA * 2n + retrievedB; // 1 TTA = 2 TTB
  const hodlValue = tenK * 2n + tenK; // 如果LP从未提供流动性,仍持有10kTTA+10kTTB

  // 允许1%容差
  const ilPercent = Number(lpValue - hodlValue) / Number(hodlValue);
  expect(Math.abs(ilPercent + 0.0571)).to.be.lessThan(0.01);
});

19.8.5 Python/JS 计算脚本:IL公式数值验证

为了直观理解无常损失的严重程度,我们用Python绘制不同价格比率下的IL曲线:

python
import numpy as np
import matplotlib.pyplot as plt

def impermanent_loss(rho):
    """rho = P1 / P0,价格比率"""
    return 2 * np.sqrt(rho) / (1 + rho) - 1

rho = np.linspace(0.1, 10, 500)
il = impermanent_loss(rho) * 100  # 转换为百分比

plt.figure(figsize=(10, 6))
plt.plot(rho, il, 'b-', linewidth=2)
plt.axhline(0, color='gray', linestyle='--', alpha=0.5)
plt.axvline(1, color='gray', linestyle='--', alpha=0.5)
plt.xlabel('Price Ratio (ρ = P₁ / P₀)')
plt.ylabel('Impermanent Loss (%)')
plt.title('Impermanent Loss vs Price Ratio')
plt.grid(True, alpha=0.3)

# 标注几个关键点
for r in [0.5, 1, 2, 4]:
    loss = impermanent_loss(r) * 100
    plt.annotate(f'ρ={r}: {loss:.1f}%', xy=(r, loss), xytext=(r, loss-3),
                 arrowprops=dict(arrowstyle='->', lw=1, color='red'))

plt.tight_layout()
plt.savefig('impermanent_loss.png', dpi=150)
plt.show()

关键数值速查

价格比率 ρ无常损失
0.5 (跌50%)-5.72%
1.0 (不变)0%
2.0 (涨100%)-5.71%
4.0 (涨300%)-20.0%
10.0 (涨900%)-41.9%

无常损失曲线关于 ρ=1\rho = 1 对称。价格偏离越远,损失增速递减但绝对值快速扩大。对于流动性提供者而言,理解IL是风险管理的第一步。

19.8.6 第19章完整工程闭环回顾

让我们站到一个更高的视角,回顾从19.1到19.8的全过程:

flowchart LR
    A[19.1-19.2<br/>AMM数学模型<br/>恒定乘积公式] --> B[19.3<br/>Swap手续费<br/>滑点保护]
    B --> C[19.4<br/>价格预言机<br/>闪电贷防御]
    C --> D[19.5<br/>前端UI<br/>钱包+交易面板]
    D --> E[19.6<br/>端到端测试<br/>全流程验证]
    E --> F[19.7<br/>合约部署<br/>源码验证]
    F --> G[19.8<br/>数学验证<br/>无常损失测试]
    G --> A

这七个小节构成了一个完整的DEX开发闭环

  1. 数学模型定义了系统的不变式(x×y=kx \times y = k)。
  2. 合约实现将数学转化为链上可执行代码。
  3. 前端产品让普通用户无需理解Solidity即可交互。
  4. 测试验证确保代码实现与数学模型等价。
  5. 部署运维将产品推向真实环境。

一个成功的DEX项目,绝不仅是几行Solidity代码——它是数学、工程、产品与安全的系统工程。

要点总结

  • 智能合约测试不仅是验证代码正确性,更是验证经济学假设的数值准确性。
  • Swap输出测试应精确到 wei 级别,确保合约实现与数学公式等价。
  • 无常损失公式 IL=2ρ1+ρ1IL = \frac{2\sqrt{\rho}}{1+\rho} - 1 是LP风险管理的核心工具,必须通过单元测试在代码层面验证。
  • DEX开发的闭环是:数学模型 → 合约实现 → 前端产品 → 测试验证 → 部署运维。

本章小结

  1. 恒定乘积公式 x×y=kx \times y = k 是 AMM 的灵魂:它同时决定了价格自动调整、滑点曲线和流动性永不枯竭的数学保障。所有 DEX 经济行为——从单笔 swap 的成交价格到 LP 的无常损失——均可从该公式推演。
  2. LP Token 是将流动性数字化的份额凭证:铸造/销毁机制确保 LP 始终按份额比例持有池中两种资产;交易手续费不直接分配,而是作为储备增量不断推高 kk 值,LP 的份额所对应的绝对资产量随之增长。
  3. 安全的流动性管理需严格遵循 Solidity 最佳实践:地址排序防重复池、最小值参数防抢跑、检查-生效-交互模式防重入——这些看似简单的模式,是 DeFi 合约从数亿级攻击中存活下来的基石。
  4. 手续费使 k 缓慢增大,LP 被动获益:AMM 的手续费不直接分配,而是通过增大池子恒乘积常数实现 LP token 的增值,这是 Uniswap V2 最核心的自动复利设计。
  5. 滑点保护是合约层与前端层的双重防线:合约层用 minAmountOut 做硬性回滚,前端层帮用户计算合理的滑点容忍值并设置 deadline,二者缺一不可。
  6. 单池瞬价不可信,时间维度是最好的骑兵:闪电贷使资本瞬间放大成为可能,但时间是区块链的天然屏障——TWAP 通过将价格评估从「瞬间」拉长到「区间」,将攻击成本从一次交易扩展到持续多区块的资本占用,从而大幅提升操纵的经济门槛。
  7. 前端是合约与用户的桥梁:即使合约层逻辑完美,没有前端UI的DEX无法触达真实用户。React + ethers.js 的三层架构(钱包层、合约层、UI层)是DApp开发的标准范式。
  8. 端到端测试是闭环验证的唯一方式:从部署测试代币、添加初始流动性、执行Swap、观察价格滑动,到移除流动性并感受无常损失——只有走完这一整圈,才能确信合约、前端、数学公式三者自洽。
  9. 部署是开发的终点,也是运营的起点:合约源码验证、多网络地址管理、前端ABI同步,这些看似琐碎的工程细节,决定了你的DEX能否被真实用户安全、可靠地使用。
  10. 测试是合约安全的最后防线:在不可篡改的链上环境中,Hardhat测试框架提供的快照、时间操纵与精确数值断言能力,是发现和预防经济漏洞的唯一手段。
  11. 数学公式必须在代码中验证:恒定乘积、手续费处理、无常损失——所有这些经济模型的正确性,最终都应体现为通过/失败的测试用例,而非仅停留在纸面推导。
  12. DEX是一个系统工程的闭环:从数学直觉到Solidity合约,从React前端到Hardhat测试,从本地开发到链上部署——每一步都不可或缺。理解这个闭环,是走向Web3产品开发者的必经之路。

评论

0

评论加载中…

发表评论

0/2000